SPDX-FileCopyrightText: 2026 Marco Lopes De Bessa & Visay Sourivong SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be
SPDX-License-Identifier: GPL-3.0-or-later
Developed on Blender 5.0.1 Saint-Germain “Deep in It”
import math
import random
import bpyCollections contenant les formes sources dessinées à la main et les configurations obtenues Elles ne doivent jamais être supprimées lors du nettoyage
protected_collections = [
"FORMES PIANO - SOURCE",
"FORMES SYNTH - SOURCE",
"_CONFIG_01",
"_CONFIG_02",
"_CONFIG_03",
"_CONFIG_04",
]Sélection de tous les objets présents dans la scène
bpy.ops.object.select_all(action="SELECT")
for obj in bpy.context.selected_objects:
if any(col.name in protected_collections for col in obj.users_collection):
obj.select_set(False)Suppression de tous les objets générés précédemment
bpy.ops.object.delete()
bpy.ops.outliner.orphans_purge()Fonction utilitaire : récupère une collection existante ou la crée si elle n’existe pas encore
def get_or_create_collection(name):
collection = bpy.data.collections.get(name)
if collection is None:
collection = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(collection)
return collectionCollections utilisées pour organiser les différents éléments du projet
col_synth = get_or_create_collection("BLOB SYNTH")
col_piano = get_or_create_collection("BLOB PIANO")
col_cube = get_or_create_collection("REFERENCE CUBE")
col_2D = get_or_create_collection("PICKED SHAPES")Création du cube de référence servant de volume de travail
bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 5))
reference_cube = bpy.context.object
reference_cube.name = "reference_cube"
reference_cube.display_type = (
"WIRE" # Affichage filaire pour visualiser les limites sans masquer les formes
)
reference_cube.hide_render = TrueRetire le cube de sa collection par défaut
for collection in list(reference_cube.users_collection):
collection.objects.unlink(reference_cube)Place le cube dans la collection REFERENCE CUBE
col_cube.objects.link(reference_cube)Dimensions du cube de référence
cube_size = 10.0
cube_min_z = 0.0
cube_max_z = 10.0Décalage vertical utilisé pour placer les projections 2D sous le cube
under_cube_offset = 2.0
under_cube_z = cube_min_z - under_cube_offset
piano_z_offset = 0Nombre minimum et maximum de formes utilisées pour construire le blob du synth
n_synth_min = 8
n_synth_max = 12Nombre minimum et maximum de formes utilisées pour construire le blob du piano
n_piano_min = 8
n_piano_max = 12Sélection aléatoire du nombre de formes pour cette génération
n_synth = random.randint(n_synth_min, n_synth_max)
n_piano = random.randint(n_piano_min, n_piano_max)Paramètres du Solidify : contrôlent l’épaisseur du blob
solidify_synth_value = random.uniform(1.2, 1.7)
solidify_piano_value = random.uniform(1.2, 1.7)Paramètres du Remesh : contrôlent le niveau de fusion du blob
voxel_synth = random.uniform(0.2, 0.4)
voxel_piano = random.uniform(0.2, 0.4)Récupération des collections contenant les formes sources
source_synth_col = bpy.data.collections.get("FORMES SYNTH - SOURCE")
source_piano_col = bpy.data.collections.get("FORMES PIANO - SOURCE")Transformation des collections en listes d’objets
all_synth_shapes = list(source_synth_col.objects)
all_piano_shapes = list(source_piano_col.objects)Sélection aléatoire des formes qui seront utilisées pour cette génération
picked_synth = random.sample(all_synth_shapes, n_synth)
picked_piano = random.sample(all_piano_shapes, n_piano)Fonction de sécurité : empêche une valeur de dépasser les limites autorisées
def clamp(value, min_value, max_value):
return max(min_value, min(max_value, value))Fonction principale du projet : copie les formes sélectionnées, les positionne dans le cube puis construit progressivement les blobs
def stack_shapes(
picked_shapes, start_corner, end_corner, target_collection, z_start, label
):
number_of_shapes = len(
picked_shapes
) # nombre total de formes à placer dans le blob
slice_height = (
cube_max_z - z_start
) / number_of_shapes # découpe virtuelle du cube en plusieurs strates verticales
current_z = z_start # hauteur de départ pour empiler les formesLimites du cube sur les axes X et Y
x_min = -cube_size / 2
x_max = cube_size / 2
y_min = -cube_size / 2
y_max = cube_size / 2Parcours de chaque forme sélectionnée
for index, source_obj in enumerate(picked_shapes, start=1):Copie de la forme source
new_obj = source_obj.copy()
new_obj.data = source_obj.data.copy()Nommage de la copie selon le type de blob
new_obj.name = label + "_shape_" + str(index)
new_obj.data.name = new_obj.name + "_mesh"Ajout de la copie dans la collection cible
target_collection.objects.link(new_obj)Rendre l’objet actif
bpy.ops.object.select_all(action="DESELECT")
new_obj.select_set(True)
bpy.context.view_layer.objects.active = new_objAppliquer les transformations de départ et recentrer l’origine sur la géométrie
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY", center="BOUNDS")Rotation aléatoire en Z par angles de 90 degrés
possible_rotations = [0, math.pi / 2, math.pi, 3 * math.pi / 2]
new_obj.rotation_euler = (0, 0, random.choice(possible_rotations))
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)Dimensions après rotation
dim_x = new_obj.dimensions.x if new_obj.dimensions.x > 0 else 1
dim_y = new_obj.dimensions.y if new_obj.dimensions.y > 0 else 1Scale uniforme pour faire rentrer la forme dans les limites du cube
uniform_scale = min(cube_size / dim_x, cube_size / dim_y)
new_obj.scale = (uniform_scale, uniform_scale, 1.0)
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)Dimensions finales utilisées ensuite pour vérifier que la forme ne sort pas du cube
dim_x_final = new_obj.dimensions.x
dim_y_final = new_obj.dimensions.y---------------------------------------------------- POSITION X/Y
Valeur comprise entre 0 et 1 utilisée pour répartir progressivement les formes le long de la trajectoire du blob
if number_of_shapes == 1:
progression = 0.0
else:
progression = (index - 1) / (number_of_shapes - 1)Trajectoire suivie par les formes entre les deux coins du cube
location_x = start_corner[0] + progression * (end_corner[0] - start_corner[0])
location_y = start_corner[1] + progression * (end_corner[1] - start_corner[1])Léger décalage aléatoire pour éviter une disposition trop régulière
location_x += random.uniform(-0.6, 0.6)
location_y += random.uniform(-0.6, 0.6)Limites pour empêcher la forme de sortir du cube
free_x_min = x_min + dim_x_final / 2
free_x_max = x_max - dim_x_final / 2
free_y_min = y_min + dim_y_final / 2
free_y_max = y_max - dim_y_final / 2Vérification finale : la forme reste entièrement contenue dans le cube
location_x = clamp(location_x, free_x_min, free_x_max)
location_y = clamp(location_y, free_y_min, free_y_max)---------------------------------------------------- TOUCH CUBE FACE Contrainte géométrique : chaque forme doit toucher au moins une face du cube Cela renforce l’idée que le cube “sculpte” le blob
Choix aléatoire de la face à toucher
if random.random() < 0.5:touche une face X
if location_x < 0:
location_x = free_x_min
else:
location_x = free_x_maxtouche une face Y
elif location_y < 0:
location_y = free_y_min
else:
location_y = free_y_max---------------------------------------------------- POSITION Z
Placement vertical aléatoire dans la strate attribuée à la forme
location_z = current_z + random.uniform(slice_height * 0.1, slice_height * 0.9)
current_z += slice_height
new_obj.location = (location_x, location_y, location_z)Création d’une projection 2D de la forme sous le cube Permet de visualiser les formes sources utilisées pour générer le blob
new_obj_picked = new_obj.copy()
new_obj_picked.data = new_obj.data.copy()
new_obj_picked.name = label + "_shape_2D_" + str(index)
new_obj_picked.data.name = new_obj_picked.name + "_mesh"
new_obj_picked.location = (location_x, location_y, under_cube_z)
col_2D.objects.link(new_obj_picked)
new_obj_picked.display_type = "WIRE"Génération du blob synth : progression du coin inférieur gauche vers le coin supérieur droit
stack_shapes(
picked_shapes=picked_synth,
start_corner=(-cube_size / 2, -cube_size / 2),
end_corner=(cube_size / 2, cube_size / 2),
target_collection=col_synth,
z_start=cube_min_z,
label="synth",
)Génération du blob piano : progression inverse pour créer une trajectoire complémentaire au synth
stack_shapes(
picked_shapes=picked_piano,
start_corner=(cube_size / 2, cube_size / 2),
end_corner=(-cube_size / 2, -cube_size / 2),
target_collection=col_piano,
z_start=cube_min_z + piano_z_offset,
label="piano",
)Fusionne toutes les formes d’une collection en un seul objet
def join_collection_into_blob(collection, blob_name):Récupération de tous les objets présents dans la collection
objects_to_join = list(collection.objects)
bpy.ops.object.select_all(action="DESELECT")Sélection de tous les objets à fusionner
for obj in objects_to_join:
obj.select_set(True)
bpy.context.view_layer.objects.active = objects_to_join[0]
bpy.ops.object.join() # Assemblage des formes en un blob unique
blob = bpy.context.object
blob.name = blob_name
blob.data.name = blob_name + "_mesh"Renvoie l’objet fusionné afin qu’il puisse être réutilisé dans les étapes suivantes
return blobsynth_blob = join_collection_into_blob(col_synth, "synth_blob")Ajout d’épaisseur au blob
solidify_synth = synth_blob.modifiers.new(name="solidify_synth", type="SOLIDIFY")
solidify_synth.thickness = solidify_synth_value
solidify_synth.offset = 0.0
solidify_synth.use_even_offset = TrueFusion des formes en une géométrie continue
remesh_synth = synth_blob.modifiers.new(name="remesh_synth", type="REMESH")
remesh_synth.mode = "VOXEL"
remesh_synth.voxel_size = voxel_synth
remesh_synth.use_smooth_shade = TrueLissage du blob final
subd_synth = synth_blob.modifiers.new(name="subd_synth", type="SUBSURF")
subd_synth.levels = 3
subd_synth.render_levels = 3piano_blob = join_collection_into_blob(col_piano, "piano_blob")Ajout d’épaisseur au blob
solidify_piano = piano_blob.modifiers.new(name="solidify_piano", type="SOLIDIFY")
solidify_piano.thickness = solidify_piano_value
solidify_piano.offset = 0.0
solidify_piano.use_even_offset = TrueFusion des formes en une géométrie continue
remesh_piano = piano_blob.modifiers.new(name="remesh_piano", type="REMESH")
remesh_piano.mode = "VOXEL"
remesh_piano.voxel_size = voxel_piano
remesh_piano.use_smooth_shade = TrueLissage du blob final
subd_piano = piano_blob.modifiers.new(name="subd_piano", type="SUBSURF")
subd_piano.levels = 2
subd_piano.render_levels = 2Création du matériau métallique blanc du synth
mat_synth = bpy.data.materials.new(name="mat_synth")
mat_synth.use_nodes = True
mat_synth.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (
1,
1,
1,
1.0,
)
mat_synth.node_tree.nodes["Principled BSDF"].inputs["Metallic"].default_value = 1Attribution du matériau au blob du synth
synth_blob.data.materials.append(mat_synth)Création du matériau métallique mauve du piano
mat_piano = bpy.data.materials.new(name="mat_piano_mauve_fonce")
mat_piano.use_nodes = True
piano_bsdf = mat_piano.node_tree.nodes["Principled BSDF"]
piano_bsdf.inputs["Base Color"].default_value = (0.136, 0.044, 0.120, 0.502)
piano_bsdf.inputs["Metallic"].default_value = 1Attribution du matériau au blob du piano
piano_blob.data.materials.append(mat_piano)Masque les formes sources afin d’éviter qu’elles se superposent aux blobs finaux
source_synth_col.hide_viewport = True
source_piano_col.hide_viewport = Truebpy.ops.object.select_all(action="DESELECT")col_tubes = get_or_create_collection("TUBES GRID")Supprime les anciens tubes pour éviter les doublons lors d’une nouvelle génération
for obj in list(col_tubes.objects):
bpy.data.objects.remove(obj, do_unlink=True)Nombre de tubes généré aléatoirement Représente la présence des drums
n_tubes_x = random.randint(4, 8)
n_tubes_y = random.randint(4, 8)Marge conservée entre les tubes et les bords du cube
margin = 0.6Espace réellement disponible pour répartir les tubes
usable_size_x = cube_size - 2 * margin
usable_size_y = cube_size - 2 * marginDistance entre deux tubes voisins sur la grille
step_x = usable_size_x / (n_tubes_x - 1) if n_tubes_x > 1 else 0
step_y = usable_size_y / (n_tubes_y - 1) if n_tubes_y > 1 else 0Position de départ de la grille de tubes
start_x = -cube_size / 2 + margin
start_y = -cube_size / 2 + marginLes tubes descendent tous 2 unités sous le cube
tube_bottom_z = under_cube_zEpaisseur des tubes générée aléatoirement Représente l’intensité des drums
tube_radius = random.uniform(0.04, 0.125)Compteur utilisé pour donner un nom unique à chaque tube
tube_index = 1Parcours de la grille pour créer chaque tube
for ix in range(n_tubes_x):
for iy in range(n_tubes_y):Calcul de la position du tube sur la grille
x = start_x + ix * step_x
y = start_y + iy * step_y---------------------------------------------------- HEIGHT Tous les tubes montent jusqu’au sommet du cube
tube_top_z = cube_max_zLe tube commence toujours à -2
tube_height = tube_top_z - tube_bottom_z
tube_center_z = tube_bottom_z + tube_height / 2---------------------------------------------------- RADIUS Épaisseur commune à tous les tubes de cette génération
radius = tube_radius---------------------------------------------------- CREATE CYLINDER
bpy.ops.mesh.primitive_cylinder_add(
vertices=24,
radius=radius,
depth=tube_height,
location=(x, y, tube_center_z),
)
tube = bpy.context.object
tube.name = "tube_" + str(tube_index)Déplace le tube dans la collection TUBES GRID
for collection in list(tube.users_collection):
collection.objects.unlink(tube)
col_tubes.objects.link(tube)
tube_index += 1bpy.ops.object.select_all(action="DESELECT")